iT邦幫忙

2023 iThome 鐵人賽

DAY 17
0
Modern Web

起步Go!Let's Go!系列 第 17

[ Day 17 ] Go 方法 (Method):物件導向的契約

  • 分享至 

  • xImage
  •  

Go 並不是以類別為基礎的物件導向設計,而是以 Struct 及 Method 為基礎的設計,這使 Go 非常適合處理網路和並行程式設計,並能快速地編譯和執行。
儘管 Go 沒有傳統的類別繼承和多型的概念,但是它仍然有封裝、繼承和多型的特性。例如,在 Go 中,可以透過結構 struct 和方法 method 的搭配實現封裝的概念。而多型則可以透過接口 interface 來實現。雖然 Go 的物件導向設計與其他語言有所不同,但是它仍然可以實現物件導向的程式設計。

Method 是什麼?

在 Go 中,方法(Method)是一個好用的東西,它可以封裝一個功能,並且可以重複使用,可以讓我們寫出更簡潔的程式碼。
它大致長這樣:

package main
import "fmt"

type Rectangle struct {
    width, height float64
}

func (r Rectangle) Area() float64 {
    return r.width * r.height
}

先定義 RectangleStruct,接著,定義一個 Area()Method,並在裡面寫計算面積的公式,這麼一來其他的函式需要計算長方形面積就可以使用這個 Method

題外話:
Ruby 有關 Method 的介紹可以參考這篇文章:Ruby 使用一些方法吧

舉一個例子,讓我們去比較不使用 Method 與使用 Method 的差別,所謂的沒有比較沒有傷害。

不使用 Method

先來定義一個 Struct 叫做長方形 ( Rectangle ),現在要計算它的面積,一般的思路會是下方這樣:

package main
import "fmt"

type Rectangle struct{
    width, height float64
}

func area(r Rectangle) float64{
    return r.width * r.height
}

func main(){
    r1 := Rectangle{12, 2}
    r2 := Rectangle{9, 4}
    fmt.Println("Area of r1:", area(r1))
    fmt.Println("Area of r2:", area(r2))
}

執行結果
Area of r1: 24
Area of r2: 36

上面的程式碼可以計算出長方形的面積,但要注意一點,area() 不是作為 Rectangle 的方法 (類似物件導向裡面的方法) 去實現的,而是將 Rectangle 的物件作為參數傳入 area() 去計算出面積的。
這樣做當然沒什麼問題,但是未來要增加圓型、正方形、三角形甚至其他多邊型,當要計算它們的面積,那以上面的方法,你就只能增加新的函式,函式名也必須更換,變成 area_rectangle, area_circle, area_triangle...


上圖為 MethodStruct 的關係圖。
可以發現函式並不屬於 Struct,反倒是單獨存在於 Struct 外,而非概念上屬於某個 Struct
以上的概念,用物件導向的術語來說,就是不屬於某個 class

使用 Method

從上面的例子來看,這樣的寫法並不是很優雅,並且從概念來說面積形狀的一種屬性,它是屬於這個特定形狀的,就像長方形的長和高一樣。
這時就可以使用 MethodMethod 是附屬在一個給定的型別 (type) 上,它的語法與函式宣告的語法幾乎一樣,只是多了一個 receiver 也就是 Method 所依從的主體。

Method 的語法長這樣:

func (r ReceiverType) funcName(paramters) (results){
    // code
}
  • func: 宣告一個方法
  • (r ReceiverType): 表示宣告一個接收器(receiver),接收器是一個類型 (Type) 可以是一個指標 (Pointer) 或是一個值 (Value),它位於 func 關鍵字和方法名稱之間,用來綁定方法的作用對象。ReceiverType 就是方法所屬的類型 (Type)。
  • funcName: 是這個 Method 的名字,用來識別這個 Method,通常使用駝峰式命名法。
  • parameters: 是這個 Method 的參數列表,可以是任意數量的參數。
  • results: 是方法的返回值,和普通函式一樣,可以有多個返回值,如果沒有返回值,則可以省略。

注意!!!!!
在使用 Method 時要注意以下幾點:

  1. 雖然 Method 的名字一模一樣,但是接收者不一樣,那麼 Method 就會不一樣。
  2. Method 裏可以存取接收者的欄位。
  3. 呼叫 Method 是透過 . 存取,就像 Struct 存取欄位一樣。
    :::

我們將最一開始的例子用 Method 改寫:

package main
import (
    "fmt"
    "math"
)

type Rectangle struct{
    width, height float64
}

type Circle struct{
    radius float64    
}

func (r Rectangle) area() float64{
    return r.width * r.height
}
func (c Cricle) area() float64{
    return c.radius * math.Pi
}

func main(){
    r1 := Rectangle{12, 2}
    r2 := Rectangle{9, 4}
    c1 := Circle{10}
    c2 := Circle{69}
    
    fmt.Println("Area of r1: ", r1.area())
    fmt.Println("Area of r2: ", r2.area())
    fmt.Println("Area of c1: ", c1.area())
    fmt.Println("Area of c2: ", c2.area())
}

執行結果
Area of r1: 24
Area of r2: 36
Area of c1: 18.84955592153876
Area of c2: 72.25663103256524

從上面的例子來看,方法 Method area() 是依賴某個形狀來發生作用的,像是 CircleCircle.area() 的發出者是 Circle,而 area() 是屬於 Circle 的方法,而不是外面的函式。
具體說,Circle 存在欄位 radius,同時存在方法 area(), 這些欄位和方法都屬於 Circle。而 Rectangle 存在欄位 heightwidth, 也同時存在方法 area(), 這些欄位和方法都屬於 Rectangle

把上面的概念畫成圖會比較清楚:

Method area() 分別屬於 RectangleCircle, 於是他們的 Receiver 就變成了 RectangleCircle, 或者說,這個 area()方法 是由 Rectangle/Circle 發出的。

Method 還能在別的地方起作用?!

從一開始到到現在,只看到 Method 只作用於 Struct 上,但其實它還可以定義在任何你自訂的型別、內建型別、Struct等各種型別上。

自訂型別?!

等等!什麼事自訂的行別,不就是 Struct 嗎?其實不然,Sturct 只是自訂型別中比較特殊的型別,還有其他自訂型別宣告,可以透過以下的宣告來實現。
自訂型別長得像以下這樣:

type typeName typeLiteral

typeLiteral : 是該新資料型別所基於的現有資料型別。typeLiteral 可以是任何一種現有資料型別,例如 intfloatstringbool 等等,也可以是結構體、介面、陣列等自定義型別。
舉例來說:

type ages int

type money float32

type months map[string]int

m := months {
    "January":31,
    "February":28,
    ...// 省略
    "December":31,
}

是不是很簡單!這樣就可以在程式碼中定義有意義的型別了。

來定義超多的 Method !!

既然知道自訂型別是什麼了!那就可以來定義超多複雜的 Method 了。

package main

import "fmt"

const(
    WHITE = iota
    BLACK
    BLUE
    RED
    YELLOW
)

type Color byte

type Box struct {
    width, height, depth float64
    color Color
}

type BoxList []Box //a slice of boxes

func (b Box) Volume() float64 {
    return b.width * b.height * b.depth
}

func (bl BoxList) BiggestColor() Color {
    v := 0.00
    k := Color(WHITE)
    for _, b := range bl {
        if bv := b.Volume(); bv > v {
            v = bv
            k = b.color
        }
    }
    return k
}

func (bl BoxList) PaintItBlack() {
    for i := range bl {
        bl[i].SetColor(BLACK)
    }
}

func (b *Box) SetColor(c Color) {
    b.color = c
}

func (c Color) String() string {
    strings := []string {"WHITE", "BLACK", "BLUE", "RED", "YELLOW"}
    return strings[c]
}

func main() {
    boxes := BoxList {
        Box{4, 4, 4, RED},
        Box{10, 10, 1, YELLOW},
        Box{1, 1, 20, BLACK},
        Box{10, 10, 1, BLUE},
        Box{10, 30, 1, WHITE},
        Box{20, 20, 20, YELLOW},
    }

    fmt.Printf("We have %d boxes in our set\n", len(boxes))
    fmt.Println("The volume of the first one is", boxes[0].Volume(), "cm³")
    fmt.Println("The color of the last one is",boxes[len(boxes)-1].color.String())
    fmt.Println("The biggest one is", boxes.BiggestColor().String())

    fmt.Println("Let's paint them all black")
    boxes.PaintItBlack()
    fmt.Println("The color of the second one is", boxes[1].color.String())

    fmt.Println("Obviously, now, the biggest one is", boxes.BiggestColor().String())
}

看到上面東西是不是頭昏眼花了呢?馬上解釋上面的程式碼是在做甚麼。
首先,先自訂一些型別:

  • 先透過 const 定義一些常數
  • Color 作為 byte 的別名
  • 定義了一個 struct:Box,含有三個長寬高欄位和一個顏色屬性
  • 定義了一個 slice:BoxList,含有 Box

接著以上面自訂型別為接收者定義一些 method:

  • Volume()定義了接收者為 Box,回傳 Box 的容量
  • SetColor(c Color),把 Box 的顏色改為 c
  • BiggestColor()定在在 BoxList 上面,回傳 list 裡面容量最大的顏色
  • PaintItBlack()把 BoxList 裡面所有 Box 的顏色全部變成黑色
  • String()定義在 Color 上面,回傳 Color 的具體顏色(字串格式)

上面的程式碼透過文字描述出來之後是不是很簡單?我們一般解決問題都是透過問題的描述,去寫相應的程式碼實現。

Method 的限制

先來看這個例子:

package main
import "fmt"
func (n int) absoulte() int{
    if n < 0 {
        return -n
    }
    return n
}
func main)(){
    num := 3
    fmt.Println(num.absoulte())
}

執行結果
cannot define new methods on non-local type int
num.absoulte undefined (type int has no field or method absoulte) (exit status 1)

我們可以先用自訂型態來實現。

package main
import "fmt"
type myInt int
func (n myInt) absoulte() myInt{
    if n < 0 {
        return -n
    }
    return n
}
func main)(){
    var num int = -3
    fmt.Println(num.absoulte())
    fmt.Println(num)
}

執行結果
3
-3

這邊要注意,上面的例子是用非指標型態定義方法,所以在呼叫 absoulute() 時,只是複製了變數 num 過去,並將結果回傳回來,原本的 num 並沒有更改。所以當印出 num 時才會是 -3。
如果想要更改原本的變數num,可以用指標的方式來實作:

package main
import "fmt"
type myInt int
func (n *myInt) absoulte() myInt{
    if *n < 0 {
        *n = -*n
    }
    return *n
}
func main)(){
    var num int = -3
    pointer := &num
    fmt.Println(num.absoulte())
    fmt.Println(num)
}

執行結果
3
3

用指標作為接收者 Receiver

我們再回頭看上面例子的 Method setColor(),有沒有發現有什麼不一樣呢?
這個 Methodreceiver 是指向 Box 的指標,那為什麼是用指標 *Box 而不是 Box 本身呢?

首先,定義 setColor 主要的目的是想改變這個 Box 的顏色,如果不傳 Box 的指標,那麼 setColor() 接受的其實是 Box 的一個 copy,也就是說 Method 內對於顏色值的修改,其實只作用於 Box 的 copy,而不是真正的 Box。所以我們需要傳入指標。

這裡可以把 receiver 當作 Method 的第一個參數來看,然後結合前面函式講解的傳值和傳參考就不難理解。

那是不是應該在 setColor() 中定義 *b.color = c 才對,因為需要讀取到指標相應的值。
沒錯!Go 中這兩種方式都是正確的,當你用指標去存取相應的欄位時(雖然指標沒有任何的欄位),Go 就知道你要透過指標去取得這個值。

也許細心的你會問這樣的問題,paintItBlack() 裡面呼叫 setColor() 的時候是不是應該寫成(&bl[i]).SetColor(BLACK),因為 setColor()receiver*Box,而不是 Box
你又說對了,這兩種方式都可以,因為 Go 知道 receiver 是指標,他自動幫你轉了

也就是說:

如果一個 Methodreceiver*T,你可以在一個 T 型別的變數 V 上面呼叫這個 Method,而不需要 &V 去呼叫這個 Method

類似的:

如果一個 MethodreceiverT,你可以在一個 T 型別的變數 P 上面呼叫這個 Method,而不需要 P 去呼叫這個 Method

所以,不用擔心你是呼叫的指標的 Method 還是不是指標的 Method,Go 知道你要做的一切。

Method 繼承

前面幾章我們學習了欄位的繼承,那麼你也會發現 Go 的一個神奇之處,method 也是可以繼承的。如果匿名欄位實現了一個 method,那麼包含這個匿名欄位的 struct 也能呼叫該 method。

package main

import "fmt"

type Human struct {
    name string
    age int
    phone string
}

type Student struct {
    Human //匿名欄位
    school string
}

type Employee struct {
    Human //匿名欄位
    company string
}

//在 human 上面定義了一個 method
func (h *Human) SayHi() {
    fmt.Printf("Hi, I am %s you can call me on %s\n", h.name, h.phone)
}

func main() {
    mark := Student{Human{"Mark", 25, "222-222-YYYY"}, "MIT"}
    sam := Employee{Human{"Sam", 45, "111-888-XXXX"}, "Golang Inc"}

    mark.SayHi()
    sam.SayHi()
}

上面的例子中,如果 Employee 想要實現自己的 SayHi,怎麼辦?簡單,和匿名欄位衝突一樣的道理,我們可以在 Employee 上面定義一個 method,重寫了匿名欄位的方法。請看下面的例子

package main

import "fmt"

type Human struct {
    name string
    age int
    phone string
}

type Student struct {
    Human //匿名欄位
    school string
}

type Employee struct {
    Human //匿名欄位
    company string
}

//Human 定義 method
func (h *Human) SayHi() {
    fmt.Printf("Hi, I am %s you can call me on %s\n", h.name, h.phone)
}

//Employee 的 method 重寫 Human 的 method
func (e *Employee) SayHi() {
    fmt.Printf("Hi, I am %s, I work at %s. Call me on %s\n", e.name, e.company, e.phone) //Yes you can split into 2 lines here.
}

func main() {
    mark := Student{Human{"Mark", 25, "222-222-YYYY"}, "MIT"}
    sam := Employee{Human{"Sam", 45, "111-888-XXXX"}, "Golang Inc"}

    mark.SayHi()
    sam.SayHi()
}

透過這些內容,我們可以設計出基本的物件導向的程式了,但是 Go 裡面的物件導向是如此的簡單,沒有任何的私有、公有關鍵字,透過大小寫來實現(大寫開頭的為公有,小寫開頭的為私有),方法也同樣適用這個原則。


上一篇
[ Day 16] Go 映射 (Map) :鍵值之間的奇幻冒險
下一篇
[ Day 18 ] Go 介面 (Interface):程式碼的通用魔法
系列文
起步Go!Let's Go!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言